[HIP] [JIT] fp8_mqa_logits: hand-written gfx950 prefill indexer kernel - #5046
sumin-hong wants to merge 1 commit into
Conversation
Add a hand-written HIP implementation of the prefill FP8 MQA indexer for gfx950 alongside the existing Triton/Gluon implementation. The kernel streams K directly from HBM to registers, shares each K stream across a block of query rows, reduces 32 heads with permlane operations, and fuses the out-of-window negative-infinity fill. The host dispatch exposes the main launch parameters while providing defaults for the supported 32-head, 128-dimensional shape. Add a correctness and performance sweep covering causal, context-parallel, misaligned, empty, past-end, and packed multi-request windows. Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Sumin Hong <sumin.hong@moreh.io>
🏷️ CI GuideRuns automatically on every PR:
Extended tests (opt-in via labels):
PR title tags: |
There was a problem hiding this comment.
Pull request overview
This PR introduces a new gfx950-only, fixed-shape (n_heads=32, head_dim=128) hand-written HIP backend for the prefill fp8_mqa_logits operator, plus JIT build integration and a correctness/performance sweep to compare against the existing Triton/Gluon implementation.
Changes:
- Add a new JIT-compiled HIP op (
aiter.ops.fp8_mqa_logits) and pybind module for prefill FP8 MQA logits on gfx950. - Implement the gfx950 kernel + host dispatch path in C++/HIP with tunable launch parameters and optional fused
-inffill. - Add an
op_tests/sweep that validates masking/error metrics against the shared fp32 reference and reports performance.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| op_tests/test_fp8_mqa_logits.py | New correctness + perf sweep comparing Triton vs HIP against a shared fp32 reference across shapes/windows. |
| aiter/ops/fp8_mqa_logits.py | New Python entry point for the JIT-compiled HIP op plus an is_supported() gate for gfx950 + (32,128). |
| csrc/kernels/fp8_mqa_logits.cu | New gfx950 HIP kernel and torch-facing dispatch entry point. |
| csrc/include/fp8_mqa_logits.h | Public C++ declaration for the new op entry point. |
| csrc/pybind/fp8_mqa_logits_pybind.cu | Pybind module definition exposing fp8_mqa_logits. |
| csrc/include/rocm_ops.hpp | Adds the FP8_MQA_LOGITS_PYBIND macro to register the op with pybind. |
| aiter/jit/optCompilerConfig.json | Registers the new JIT module and applies -fno-honor-nans for the kernel TU. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const int M = q_fp8.size(0); | ||
| const int N = k_fp8.size(0); | ||
|
|
||
| TORCH_CHECK(q_fp8.size(1) == NUM_HEADS && q_fp8.size(2) == HEAD_SIZE, | ||
| "Only n_heads=32, head_dim=128 supported"); | ||
| TORCH_CHECK(k_fp8.size(1) == HEAD_SIZE, "K must be [N, 128]"); | ||
| TORCH_CHECK(q_fp8.is_contiguous() && k_fp8.is_contiguous(), | ||
| "q_fp8 and k_fp8 must be contiguous"); | ||
|
|
|
Results from a production-style deployment: GLM-5.3 full model (own Quark checkpoint, MXFP4 experts, FP8 block-scaled attention) on 4× MI355X (gfx950), TP4, ROCm 7.2.3, vLLM
Thanks for the kernel. Happy to run further shapes or a rebased version if useful. |
I have attached the additional experiment results to the PR description. |
Motivation
The DeepSeek-V3.2 / GLM-5 "lightning indexer" produces the sparse-attention
selection logits. For each query row
mand KV positionn:In the prefill path the caller has already gathered K out of the paged cache into
a contiguous
[N, 128]buffer with a separate[N]fp32 scale, so there is noblock table and no preshuffle. This PR adds a hand-written HIP kernel for that
path on gfx950 (CDNA4), alongside the existing Triton/Gluon
aiter.ops.triton.attention.fp8_mqa_logits. The paged decode half is #5047.cu_seqlen_ksis a request's base offset into the gathered K buffer andcu_seqlen_kegrows by one per query row, so each request is a causal triangleand several requests arrive packed into one
[M, N]chunk. Chunks are sized soM*N*4stays underVLLM_SPARSE_INDEXER_MAX_LOGITS_MB, which makes a 32Krequest
(4096, 32768)and a 128K one(1024, 131072).Technical Details
New op:
aiter/ops/fp8_mqa_logits.pyfp8_mqa_logitsis a drop-in for the Triton entry point -- same tensors(
q_fp8,k_fp8,kv_scale,weights,cu_seqlen_ks,cu_seqlen_ke) and thesame
clean_logitssemantics. Key design points:tokens per
mfma_scale_f32_32x32x64_f8f6f4tile.BLOCK_Mquery rows share one K stream, so K is read once per row blockrather than once per row. Grid is
(ceil(M / BLOCK_M), SplitN);SplitNsplits each block's KV tile range so a small row grid still fills the device.
streams in flight per block. Splitting rows across warps instead -- so K is read
once per
BLOCK_M * NUM_WARPSrows rather than once perBLOCK_M-- measured1.5-23% slower: the latency hiding is worth more than the L2 traffic it saves.
v_permlane32_swap_b32head reduce.v_permlane32_swap_b32 a, bleaves a's reduction in lanes 0-31 and b's in lanes 32-63, so two query rows
reduce with one swap and one add, and the store that follows uses all 64 lanes
instead of half.
kv_scale >= 0and ReLU is positive-homogeneous, so thescale is applied once per KV column after the head reduction rather than inside
it.
N > 2048. Under causal masking arow group's work grows with
m, so in natural order the longest-running blocksare dispatched LAST and become the tail; reversing starts them first and lets
the short ones fill in behind. Worth +6% at
(4096, 4096)and +3% at(8192, 8192), neutral elsewhere, and free -- it only reorders block dispatch.-inffill. Withclean_logitsthe kernel writes the-infoutsideeach row's window itself. The Triton path pre-fills all
M*Nelements withtorch.fulland then overwrites the valid ones, paying for the valid regiontwice.
-fno-honor-nansfor the module, so the ReLU is a singlev_max_f32.Without it LLVM must assume a signalling NaN and emits an IEEE canonicalize
first -- two VALU per accumulator value, which is ~27% of the kernel's VALU.
BlockM,SplitN,num_warps,unroll2andreverse_rowsare all tunable;zero means "use the host heuristic".
The kernel is gfx950-only and fixed at
n_heads=32, head_dim=128-- the shippedGLM-5-FP8 indexer shape.
is_supported(num_heads, head_dim)gates on that, so acaller that also serves other shapes can route them to the Triton kernel rather
than trip a
TORCH_CHECK. This mirrors how_should_use_asm_kernelgates thehead_size=128-only ASM paged-attention kernel in
aiter/ops/attention.py.Files added / changed:
aiter/ops/fp8_mqa_logits.py-- the op and its support gatecsrc/kernels/fp8_mqa_logits.cu-- kernel and host dispatchcsrc/include/fp8_mqa_logits.h,csrc/pybind/fp8_mqa_logits_pybind.cucsrc/include/rocm_ops.hpp,aiter/jit/optCompilerConfig.json--module_fp8_mqa_logitsop_tests/test_fp8_mqa_logits.py-- correctness + perf sweepTest Plan
op_tests/test_fp8_mqa_logits.pyruns Triton and HIP on identical inputs andgrades both against one fp32 torch reference -- the same
ref_fp8_mqa_logitstheTriton lane's test uses. Gates are an exact
-infmask match pluscalc_diff < 1e-3andcheckAllclose; tolerances are not widened.The sweep is the cartesian product of 14
(s_q, s_k)shapes,num_heads in {32,64,128},head_dim in {64,128},clean_logits in {0,1}andsix window modes -- 900 cases, 150 of which the HIP kernel supports. Points worth
calling out:
causalandcp, the sweep coversmisaligned,empty(rows withcu_endsbelow zero or belowcu_starts),past_end(bounds beyond
seq_len_kv) andmulti_req(several requests packed into onechunk, so
cu_startsjumps at each boundary and a block's rows straddle it).All are legal indexer input at a chunk boundary, and all are where the masking
and the
-inffill are easiest to get wrong. Grading the HIP kernel underpast_endcaught two out-of-bounds writes during development, both on theclean_logits=Falsepath.so a position the kernel fails to write fails the
-infmask check. Without itthe check is close to vacuous -- the caching allocator hands back a block a
previous case already left holding the correct
-inf.(4096, 32768),(2048, 65536),(1024, 131072).The reference runs in query-row chunks so its
[heads, s_q, s_k]score tensorstays bounded (unchunked it is 17 GiB at
heads=32, s_q=1024, s_k=131072).nanrather thanreporting a wrong-but-fast number, and any case dropped for lack of memory is
logged by name so a short table cannot read as full coverage.
Test Result
All correctness gates pass on gfx950 across the sweep; per-case
hip errmatchestriton err. Grading the HIP kernel underpast_endcaught two out-of-boundswrites on the
clean_logits=Falsepath (acu_startspastseq_len_kvleft thefill's first range unclamped, and the store bounded
abs_posonly bycu_ends);both are fixed here.
Performance on MI355x/gfx950,
num_heads=32,head_dim=128,run_perfteston anotherwise idle GPU.
causalis one request per chunk,multi_reqis four:Summarised over the 32-shape sweep (both
clean_logitssettings, both windows):causalmulti_reqclean_logits=Trueclean_logits=FalseThe win tracks
s_k / s_q, which is what the shared-K-stream design predicts:BLOCK_Mrows amortise one K read, so the longer a row's KV range is relative tothe row grid, the more there is to amortise. Every shape with
s_k >= 8 * s_qisa win (1.19x - 6.39x), and the largest is the small-M/long-KV corner
(128, 32768)at 6.4x, where the row grid alone cannot fill the device andSplitNdoes the work.The losses are the square, short-KV shapes --
(8192, 8192)at 0.97x/0.83x and(1024, 1024)/(4096, 4096)underclean_logits=False-- where the tile loop isshort relative to the per-block Q/weights prologue. That prologue is also what
pins the kernel at 2 waves/SIMD:
BLOCK_M=4needs 194 VGPR, of which Q and theper-row weights are 128, and both scale with
BLOCK_M, so KV reuse and registerpressure cannot be traded apart in this design (
BLOCK_M=8needs 256 VGPR andspills 113). Staging K through LDS would decouple them; until then
is_supported()plus a shape check lets a caller keep Triton on that corner.clean_logits=Trueis the better case for the HIP kernel (1.64x vs 1.43x), whichis the fused
-inffill showing up: the Triton path pays atorch.fullover alls_q * s_kelements before the kernel overwrites the valid ones.Related Work
BLOCK_Qimplementation.This PR proposes a structurally different, fixed-shape HIP backend that streams K directly from HBM to registers and shares the K stream across query rows. A direct head-to-head comparison with the active alternatives and quantitative roofline utilization remain pending.
Current Validation
mainat9aa8a6b91f1972952314bd16176a76d392f6b85cwith no overlapping-file conflicts.black==26.3.0 --check: pass for the added Python op and test.ruff==0.16.0 check: pass for the added Python op and test.python3 -m py_compile: pass for the added Python op and test.AI assistance: OpenAI Codex was used for rebase adaptation, formatting, static checks, overlap research, and PR drafting. The submitter reviewed the resulting commit.
GLM-5.2-MXFP4 E2E and accuracy (2026-09-05 update)
The following supplied experiment report addresses the E2E and accuracy request in #5046 (comment). These are reported measurements on the integration stack specified below; this documentation update did not rerun the experiments.
E2E and accuracy for this PR on GLM-5.2-MXFP4 / MI355X (gfx950) / TP4, per @nholmber's request.
Headline: 1.07x output throughput at 128k input, at every concurrency point, with accuracy held.
No measurable change at 1k input, which is expected — this is a prefill kernel and 1k prefill is a
small share of E2E work.
Setup
amd/GLM-5.2-MXFP4@386bd0e4,kv-cache-dtype=fp8_e4m3, MTP=03ff4f02df(0.28.1rc1.dev398)amd-aiter 0.1.21.post1+ this PR's files, JIT modulemodule_fp8_mqa_logits--tensor-parallel-size 4,--linear-backend aiter --moe-backend aitervllm bench serve --dataset-name random,--backend openai+/v1/completions,--random-range-ratio 0.0,--ignore-eosIntegration — one env-gated branch in
rocm_fp8_mqa_logits()(
vllm/v1/attention/ops/rocm_aiter_mla_sparse.py), since this kernel is a drop-in for theaiter.ops.triton.attention.fp8_mqa_logitsthat vLLM already calls there. GLM-5.2 isindex_n_heads=32, index_head_dim=128, sois_supported()holds and the kernel really engages.Both arms ran concurrently on one node, one 4-GPU half each, so they share wall-clock and
thermals; the only difference is the kernel:
aiter.ops.triton.attention.fp8_mqa_logits(the reference AITER kernel)aiter.ops.fp8_mqa_logits(this PR)The branch raises rather than silently falling back, and the server log confirms which kernel
each arm used: 4 provenance lines on the "after" arm (one per TP rank), 0 on "before". So the
"after" column cannot be an accidental re-measurement of "before".
128k in / 1k out
TTFT 1.07-1.13x better, TPOT 1.05-1.07x better.
Independent confirmation from wall-clock (not derived from the same JSON metrics) — each
vllm bench serveinvocation, before -> after:1k in / 1k out
Per-run wall-clock is identical to within a second at 5 of 7 points.
TTFT is omitted for this scenario: at conc 16-64 the "after" arm's points happened to run while the
other arm was loading its 408 GB of weights, and that host-side NVMe/CPU burst shows up in TTFT
(0.78-0.85x) while leaving throughput and TPOT at 1.00x. The clean points either side (conc 4, 8,
128, 256) are all 1.00x on TTFT too, so we read the dip as contention, not kernel. Happy to re-run
the 1k sweep synchronised if the TTFT column matters.
Accuracy
Validation gates and expectations from the AMD "GLM-5.2 MXFP4 - vLLM status and validation" deck.
</think>, 391niah_single_2@ 64k (500 samples)niah_single_2@ 128k (500 samples)GPQA differs by one question out of 198 (0.5 pp), well inside the +-2.1 pp standard error at that
sample size. RULER is at the ceiling on both arms at both lengths, i.e. the sparse path still selects
correctly with the new kernel.
Notes
component of E2E time, so its speedup is diluted by everything else in the step. A ~6-7% E2E gain at
long context from a single kernel is the expected shape of this result, and we'd suggest not setting
E2E expectations from the op-level numbers.
with decode batches, so faster prefill means decode steps queue behind it for less time. This effect
only shows up E2E.
default utilisation, and this model costs ~55 KB/token — about 21 requests of 132k. So conc 32 and 64
queue rather than running fully in parallel, which is why throughput saturates at ~105-112 tok/s from
conc 16 on. Identical on both arms, so the comparison holds, but the concurrency label is nominal.
--no-enable-prefix-caching). With it on, a repeated prompt skipsprefill entirely, which would hide exactly what this PR changes. This does mean absolute numbers are
not directly comparable to runs that leave it at the default.
before/after against the reference AITER kernel. We can add [FlyDSL] gfx950 FP8 MQA logits indexer kernel #4538 (the FlyDSL implementation of the
same kernel) as a third arm if it would help the decision.
Submission Checklist